Skip to content

refactor(capabilities): 引入 fs / shell 能力 seam(H1) - #358

Merged
oratis merged 2 commits into
mainfrom
claude/h1-capability-seam
Aug 14, 2026
Merged

refactor(capabilities): 引入 fs / shell 能力 seam(H1)#358
oratis merged 2 commits into
mainfrom
claude/h1-capability-seam

Conversation

@oratis

@oratis oratis commented Aug 13, 2026

Copy link
Copy Markdown
Owner

计划见 #356docs/PLAN_HARNESS_ALIGNMENT_v1.0.md §2(H1)。纯重构,对外行为零变化。 H2 沙箱长在这上面。

问题

工具此前各自 import fs from "node:fs" / spawn,把"这个工具到底在哪儿执行"写死成十四处调用点里的既成事实。路线图上有三件事被它卡住:

  • Dispatch 想把执行搬到容器或远程主机,就得给每个工具写第二份实现;
  • Cloud 多租户只能把 read/write/bash 整个从注册表里删掉(cloudSafeSubset),因为没有任何办法给它们划界 —— 按遗漏拒绝是补丁,有界执行世界才是解;
  • 测试只能对着真实磁盘跑。

做法

按 dsh 的 seam 模型分三种角色:接口capabilities/types.ts)、提供方local.ts / memory.ts)、消费方(工具)。换提供方 = 一次性换掉所有消费方的执行世界,工具代码一行不改。

几个刻意的设计选择:

决定 理由
ToolContext.caps 可选 未设即本机磁盘与 shell → 十几处已有的 ToolContext 构造点全部原样编译、行为零变化
工具一律经 capsOf(ctx) 绝不直接读 ctx.caps,本机兜底只有一处,不会有人漏写 ?? LOCAL
resolvePath 放进 fs seam 这是策略能拒绝逃逸的唯一咽喉。正因如此 H2 的沙箱是"换提供方"而不是"给七个工具各加一道检查",并从结构上保证 fs 与 shell 不会被限制到不同的根目录
shell.run(命令串)shell.exec(argv) 分成两个方法 grep 传的是模型给的 pattern,把 argv 塞进 shell 串正是注入漏洞的来源。dsh 把这两者拆成两个 seam;LISA 的体量下一个 seam 两个操作够用,但方法必须分开
LISA_SANDBOX 包装移入 localShell.run 命令跑在什么约束下是执行世界的属性,不是发起工具的属性。此处为原行为搬家

迁移的 7 个工具:read / write / edit / apply_patch / ls / grep / bash。其余工具不动 —— 它们操作的是 LISA 自己的 home,本来就该走 lisaHome()

测试(13 例)

  • 防回归断言 —— 7 个工具的源码不得 import node:fs / node:child_process,且必须出现 capsOf(ctx)。避免日后有人绕过 seam 而无人发现(正则只匹配 import 语句,不会被注释里的字符串糊弄过去)
  • 内存提供方 —— read/write/edit/ls/apply_patch 五个工具原样跑在 Map 上,并断言真实磁盘未被触碰。这不是为了测试更快,而是第二个提供方存在本身就是 seam 为真的证明
  • 有界世界 —— ../ 与绝对路径逃逸在 resolvePath 处即被拒,读写双向,且拒绝前没有发生任何 I/O
  • 无进程世界 —— bash 明确报错而不是假装成功(静默成功的 stub 会让测试通过而真实路径已坏)

本机路径实测

重构后逐条对过,与重构前一致:exit code 0 / 非零退出 3(resolve 而非 throw)/ 超时 signal=SIGTERM / grep 无匹配 / grep 命中 / ls / 沙箱开启后写 cwd 外被 Seatbelt 拒绝Operation not permitted)。

全量 1565 通过 / 0 失败,typecheck 干净。

🤖 Generated with Claude Code

工具此前各自 `import fs from "node:fs"` / `spawn`,把"这个工具到底在哪儿执行"
写死成十四处调用点里的既成事实。路线图上有三件事被它卡住:

- Dispatch 想把执行搬到容器或远程主机,就得给每个工具写第二份实现;
- Cloud 多租户只能把 read/write/bash 整个从注册表里删掉(cloudSafeSubset),
  因为没有任何办法给它们划界——**按遗漏拒绝是补丁,有界执行世界才是解**;
- 测试只能对着真实磁盘跑。

按 dsh 的 seam 模型分三种角色:接口(capabilities/types.ts)、提供方
(local.ts / memory.ts)、消费方(工具)。换提供方 = 一次性换掉所有消费方的
执行世界,工具代码一行不改。

- `ToolContext.caps?: Capabilities` — 可选,未设即本机磁盘与 shell,所以十几处
  已有的 ToolContext 构造点全部原样编译、行为零变化。
- 工具一律经 `capsOf(ctx)` 取世界,绝不直接读 `ctx.caps`,本机兜底只有一处。
- `resolvePath` 放进 fs seam 而不是各工具自己 `path.resolve`:这是策略能拒绝
  逃逸的**唯一咽喉**,也正是 H2 的沙箱能做成"换提供方"而不是"给七个工具各加
  一道检查"的原因,并从结构上保证 fs 与 shell 不会被限制到不同的根目录。
- `shell.run(命令串)` 与 `shell.exec(argv)` 是两个方法而非一个:grep 传的是
  模型给的 pattern,把 argv 塞进 shell 串正是注入漏洞的来源。dsh 把这两者拆成
  两个 seam,LISA 的体量下一个 seam 两个操作足够,但方法必须分开。
- `LISA_SANDBOX` 包装从 bash 工具移入 localShell.run——命令跑在什么约束下是执行
  世界的属性,不是发起工具的属性。此处为原行为搬家,H2 才真正重构它。

迁移的 7 个工具:read / write / edit / apply_patch / ls / grep / bash。
其余工具不动——它们操作的是 LISA 自己的 home,本来就该走 lisaHome()。

测试(13 例):
- **防回归断言**:7 个工具的源码不得 import node:fs / node:child_process,
  且必须出现 capsOf(ctx)——避免日后有人绕过 seam 而无人发现;
- 内存提供方:read/write/edit/ls/apply_patch 五个工具原样跑在 Map 上,
  并断言真实磁盘未被触碰;
- 有界世界:`../` 与绝对路径逃逸在 resolvePath 处即被拒,读写双向,且拒绝前
  没有发生任何 I/O;
- 无进程世界:bash 明确报错而不是假装成功。

本机路径实测:exit code / 非零退出 / 超时 SIGTERM / grep 无匹配 / 沙箱开启后
写 cwd 外被 Seatbelt 拒绝,均与重构前一致。全量 1565 通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis

oratis commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

⚠️ 合并时请勿加 --delete-branch —— #359(H2)以本分支为 base,删分支会连带把它自动关掉(本仓踩过这个坑,见 git log 里的 #170)。

建议合并顺序:#356(文档)→ #357(H3)→ #358(本 PR,不删分支)#359(H2,合并后再删两个分支)→ #360(P1)。

#357#359 都动了 SessionHeader(前者加 version: 2,后者加可选 sandboxMode),types.ts 会有一处文本冲突,取并集即可。

… caps.shell

Review found the "single choke point" framing overclaims: the 7 primitive
fs/shell tools route through caps, but the workspace-spawning tools via
exec-util (run_checks, compare_agents, redeploy, dispatch_agent, repo/PR/review
helpers) still use raw spawn and don't pass through caps.shell. A sandbox
provider (H2) swapped in at ctx.caps bounds the primitives but not those.
Document the true scope at the seam so H2 and future readers don't build on a
false "bound the world in one place" guarantee; routing the exec-util family
through the seam is the follow-up that makes it literally true. No behavior
change (the 7 migrations are already verified faithful).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@oratis

oratis commented Aug 14, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review — findings + fix applied

Verified every one of the 7 tool migrations (read / write / edit / apply_patch / ls / grep / bash) against its pre-seam version: no runtime regression. Return shapes, error strings (not a file:, already exists, grep exited N), throw-vs-resolve, utf8/stat.size limits, the bash MAX_OUTPUT cap + SIGTERM→SIGKILL escalation, and the sandbox-wrap ordering are all preserved; apply_patch dropping its explicit ensureDir is safe (localFs.writeFileatomicWrite already ensures the parent), and the exec vs run split correctly keeps the model's grep pattern off any shell string. Good, faithful refactor.

MED — fixed (docs)

The PR frames the seam as the single choke point ("其余工具…操作的是 LISA 自己的 home"), but that's not accurate: a family of tools spawns subprocesses in the workspace (ctx.cwd) through src/tools/exec-util.ts's raw spawn and never touches caps.shellrun_checks (npm/pnpm run …), compare_agents (git worktree …), redeploy (npm run build), dispatch_agent (claude -p / aider --yes), and the repo_digest/pr_status/review_diff helpers. So when H2 swaps ctx.caps for a sandboxed provider, bash/grep get bounded but those don't — the "bound the world in one place" guarantee is silently false for them.

Rather than balloon H1's scope, I added a scope note at the seam (capabilities/index.ts) stating exactly what routes through capsOf and flagging the exec-util family as the un-seamed follow-up, so H2 and future readers don't build on the overclaim. Routing exec-util/dispatch_agent/redeploy through caps.shell is the natural next PR.

LOW — noted, not changed

  • grep output bound changed from "lax ~256 KB + one pipe chunk" to a hard 256 KB mid-chunk slice. Harmless (256 KB is a fine cap) and only differs on >256 KB matches, but it is a deviation from "对外行为零变化" — worth a word in the description.
  • FsCapability methods trust their pre-resolved abs (only resolvePath enforces a root). Safe today; a one-line contract note that these assume an already-permitted path would keep a future path.join-then-stat tool from silently escaping the bound.

@oratis
oratis merged commit 99d629e into main Aug 14, 2026
1 check passed
@oratis
oratis deleted the claude/h1-capability-seam branch August 14, 2026 10:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant